diff options
Diffstat (limited to 'apps/web/app/shared/[token]/page.tsx')
| -rw-r--r-- | apps/web/app/shared/[token]/page.tsx | 165 |
1 files changed, 165 insertions, 0 deletions
diff --git a/apps/web/app/shared/[token]/page.tsx b/apps/web/app/shared/[token]/page.tsx new file mode 100644 index 0000000..222c1c8 --- /dev/null +++ b/apps/web/app/shared/[token]/page.tsx @@ -0,0 +1,165 @@ +import type { Metadata } from "next" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { sanitizeEntryContent } from "@/lib/sanitize" + +interface SharedPageProperties { + params: Promise<{ token: string }> +} + +interface SharedEntryRow { + entry_id: string + expires_at: string | null + entries: { + id: string + title: string | null + url: string | null + author: string | null + summary: string | null + content_html: string | null + published_at: string | null + enclosure_url: string | null + feeds: { + title: string | null + } + } +} + +async function fetchSharedEntry(token: string) { + const adminClient = createSupabaseAdminClient() + + const { data, error } = await adminClient + .from("shared_entries") + .select( + "entry_id, expires_at, entries!inner(id, title, url, author, summary, content_html, published_at, enclosure_url, feeds!inner(title))" + ) + .eq("share_token", token) + .maybeSingle() + + if (error || !data) return null + + const row = data as unknown as SharedEntryRow + + if (row.expires_at && new Date(row.expires_at) < new Date()) { + return { expired: true as const } + } + + return { expired: false as const, entry: row.entries } +} + +export async function generateMetadata({ + params, +}: SharedPageProperties): Promise<Metadata> { + const { token } = await params + const result = await fetchSharedEntry(token) + + if (!result || result.expired) { + return { title: "shared entry — asa.news" } + } + + return { + title: `${result.entry.title ?? "untitled"} — asa.news`, + description: result.entry.summary?.slice(0, 200) ?? undefined, + openGraph: { + title: result.entry.title ?? "shared entry", + description: result.entry.summary?.slice(0, 200) ?? undefined, + siteName: "asa.news", + }, + } +} + +function SanitisedContent({ htmlContent }: { htmlContent: string }) { + // Content is sanitised via sanitize-html before rendering + const sanitisedHtml = sanitizeEntryContent(htmlContent) + return ( + <div + className="prose-reader text-text-secondary" + // eslint-disable-next-line react/no-danger -- content sanitised by sanitize-html + dangerouslySetInnerHTML={{ __html: sanitisedHtml }} + /> + ) +} + +export default async function SharedPage({ params }: SharedPageProperties) { + const { token } = await params + const result = await fetchSharedEntry(token) + + if (!result) { + return ( + <div className="mx-auto max-w-2xl px-6 py-16 text-center"> + <h1 className="mb-4 text-text-primary">shared entry not found</h1> + <p className="text-text-secondary"> + this shared link is no longer available or has been removed. + </p> + </div> + ) + } + + if (result.expired) { + return ( + <div className="mx-auto max-w-2xl px-6 py-16 text-center"> + <h1 className="mb-4 text-text-primary">this share has expired</h1> + <p className="text-text-secondary"> + shared links expire after a set period. the owner may share it again if needed. + </p> + </div> + ) + } + + const entry = result.entry + const formattedDate = entry.published_at + ? new Date(entry.published_at).toLocaleDateString("en-GB", { + day: "numeric", + month: "long", + year: "numeric", + }) + : null + + return ( + <div className="mx-auto max-w-2xl px-6 py-8"> + <article> + <h1 className="mb-2 text-lg text-text-primary">{entry.title}</h1> + <div className="mb-6 text-text-dim"> + {entry.feeds?.title && <span>{entry.feeds.title}</span>} + {entry.author && <span> · {entry.author}</span>} + {formattedDate && <span> · {formattedDate}</span>} + </div> + {entry.enclosure_url && ( + <div className="mb-4 border border-border p-3"> + <audio + controls + preload="none" + src={entry.enclosure_url} + className="w-full" + /> + </div> + )} + <SanitisedContent + htmlContent={entry.content_html || entry.summary || ""} + /> + </article> + <footer className="mt-12 border-t border-border pt-4 text-text-dim"> + <p> + shared from{" "} + <a + href="/" + className="text-text-secondary transition-colors hover:text-text-primary" + > + asa.news + </a> + </p> + {entry.url && ( + <p className="mt-1"> + <a + href={entry.url} + target="_blank" + rel="noopener noreferrer" + className="text-text-secondary transition-colors hover:text-text-primary" + > + view original + </a> + </p> + )} + </footer> + </div> + ) +} |